Skip to content

Promote pool allowance hardening and paid pool access to production - #329

Merged
crtahlin merged 5 commits into
mainfrom
dev
Sep 8, 2026
Merged

Promote pool allowance hardening and paid pool access to production#329
crtahlin merged 5 commits into
mainfrom
dev

Conversation

@crtahlin

@crtahlin crtahlin commented Sep 8, 2026

Copy link
Copy Markdown

Promotes the pool work to production. Merge #328 into dev first — this PR tracks the branch and will pick it up automatically. Without it, this fixes one hole and opens another.

Why this is worth doing now

Production's daily allowance is currently decorative. main's pool_allowance._key() gives every distinct Origin string its own 20-a-day budget, so a caller can send a1.example, then a2.example, and keep drawing pooled batches indefinitely. The only backstop is STAMP_POOL_MAX_PURCHASES_PER_HOUR (10) — roughly 4.3 BZZ a day against a wallet holding 16.35 BZZ.

What lands

Unlisted origins share one bucket (#326). Only origins named in POOL_DAILY_ALLOWANCES get a budget of their own; everything else shares POOL_DEFAULT_DAILY_ALLOWANCE. Rotating the header stops minting new budgets. Origin remains attribution, not authentication — a browser cannot forge another site's, which is what stops other websites spending our postage, but any non-browser client can claim any origin. The budget is the control, not the header.

Paid pool access (#327, closes #67). /pool/acquire is priced from what the pool actually paid — STAMP_POOL_DEFAULT_DURATION_HOURS + 1 at the requested depth — plus X402_POOL_MARKUP_PERCENT, set to 100 on both environments. Previously the path matched no branch in the pricing dispatcher and fell through to X402_MIN_PRICE_USD, so a depth-20 batch was quoted the same as the floor. The router uses settle_payment_if_offered rather than require_x402_payment, because the latter answers 402 to any caller sending no payment header — which is every current caller.

The bypass is withheld on testnets (#328). See that PR: production runs X402_NETWORK=base-sepolia against the public x402.org facilitator, where USDC is free from a faucet.

Configuration

Already set on both environments, so no post-merge work:

production staging
POOL_DAILY_ALLOWANCES https://dataprovenance.app=20 https://dev.dataprovenance.app=20
POOL_DEFAULT_DAILY_ALLOWANCE 20 20
X402_POOL_MARKUP_PERCENT 100 100
X402_ALLOW_TESTNET_PAID_BYPASS unset → false unset → false

deploy.yml writes all four, so they reach the containers rather than sitting in GitHub doing nothing.

Tests

1120 passed, 25 skipped in 21s, run against dev locally. There is no CI test gate on this repo.

Known limitations, deliberate

One batch in the pool. STAMP_POOL_RESERVE_SMALL=1 on production. A paying caller who arrives while the single batch is out gets a 409 until the replacement lands, about a minute. Raising the reserve costs standing BZZ for demand that has not appeared yet, so this is worth revisiting once paid access sees real use rather than pre-emptively.

Paid access earns nothing yet. With production on base-sepolia, the markup is correct code waiting on a configuration decision. Nothing currently tracks moving to Base mainnet; that needs its own issue.

Every distinct Origin got its own bucket with the full default allowance. The
header is supplied by the caller and, for anything that is not a browser,
entirely attacker-controlled — so a caller could send a value nobody had seen,
receive a fresh allowance, and repeat. The number of buckets was unbounded and
so was the spend. The budget was decorative.

Found while verifying that dataprovenance-app needs no change: a request
carrying Origin: https://evil.example was not merely allowed, it was allocated
its own separate budget alongside the app's.

Now only origins NAMED in POOL_DAILY_ALLOWANCES get a bucket of their own, which
is what naming them is for. Everything else — unlisted origins and callers
sending no Origin at all — shares one bucket. A CLI is not privileged over an
unknown website, and neither can take the app's allowance.

Still per size in both cases, since sizes differ in cost by powers of two.

The response now reports which bucket was charged. An unlisted caller sharing
the common budget may find it already spent by somebody else, and that is much
less confusing when the response says so rather than appearing to be about them.

Four tests, including that the shared bucket does not swallow the configured
origins — the fix must not solve one problem by creating the opposite one.

Full suite: 1107 passed, 25 skipped.
* Let callers pay for a pooled batch

The pool was free-within-a-budget or nothing. A caller who exhausted the daily
allowance had no route except waiting for midnight, and no way to pay for more —
on a gateway whose whole purpose is selling access to Swarm storage.

The mechanism was already half-built. The acquire handler has always read
request.state.x402_payer and registered the batch to that wallet when present,
but the payment dependency was never attached to the pool router, so the
attribute was always None and every acquire fell to the "shared" path. That
branch has never executed in production.

require_x402_payment could not simply be attached. It answers 402 to any caller
presenting neither an X-PAYMENT header nor X-Payment-Mode: free, and the callers
this endpoint exists for present neither — a browser app sends what the browser
sends. Attaching it would have broken every existing caller in exchange for
making payment possible.

So settle_payment_if_offered: a payment presented is verified and settled exactly
as elsewhere; no payment presented is not an error, and the handler falls through
to the daily allowance.

A paid acquire bypasses the allowance and consumes none of it. The allowance
bounds what the operator gives away; it has no business limiting what someone has
paid for.

POOL acquire joins PROTECTED_ENDPOINTS so it has a price, which is what that list
means — not that a request without payment is refused. A test pins that
distinction, and that the router carries the optional dependency rather than the
mandatory one, because swapping them looks harmless and would 402 every existing
caller.

The exhausted-allowance message now offers the paid route on THIS endpoint rather
than sending the caller to direct purchase with its minute of latency. That was
the honest answer before paying here was possible; it no longer is.

Full suite: 1109 passed, 25 skipped.

* Charge a premium for a pooled batch, and price it at all

Two things, one of which was a plain defect.

/pool/acquire matched no branch in the pricing dispatcher and fell through to the
final return: X402_MIN_PRICE_USD, described as "Gateway operation", regardless of
what was requested. A depth-20 batch costs eight times a depth-17 one, so the
quote bore no relation to the batch handed over. It was harmless only because
paying for a pooled batch was impossible until now — the moment payment works,
it would have sold every size at the floor.

It is now priced from the requested depth, then marked up by
X402_POOL_MARKUP_PERCENT, default 100 (double).

The premium is the point rather than an afterthought. The pool is a different
product from a purchase: the capital is already spent, the batch is kept alive
whether or not anyone takes it, and it may expire unused. What the caller buys is
the absence of the minute an on-chain purchase takes, and that convenience has a
real cost behind it.

Priced from what the pool PAID — STAMP_POOL_DEFAULT_DURATION_HOURS + 1, matching
_purchase_stamp, where the extra hour clears Bee's minimum-validity floor — not
from the TTL the caller receives. Pricing off the remaining TTL would fluctuate
per request and would charge least for the batch that had cost most to keep
alive.

A negative markup cannot discount below cost; misconfiguration should not sell
batches for less than they cost. Pinned by a test.

At the current chain price, with production's 24h setting: a pooled small quotes
$0.0145 at cost and $0.029 with the premium, against $0.0139 for a direct 24h
purchase. A medium is eight times that.

Full suite: 1114 passed, 25 skipped.

* Write the pool markup to the servers

X402_POOL_MARKUP_PERCENT was not in deploy.yml, so setting it as a repository
variable would have had no effect — the same propagation trap that already
caught the allowance settings, checked for this time rather than discovered
afterwards.

Defaults to 100 in the workflow, matching the code, so a host that has not set
the variable behaves identically either way.
/pool/acquire lets a settled x402 payment bypass the daily allowance, on the
reasoning that the budget bounds what the operator gives away rather than what
someone has paid for. That reasoning only holds where the payment is worth
something.

Production runs X402_NETWORK=base-sepolia against the public x402.org
facilitator. USDC there is free from a faucet, so shipping the bypass unguarded
would have replaced a bounded giveaway with an unbounded one: any caller could
top up for nothing and draw batches bought with real BZZ, capped only by
STAMP_POOL_MAX_PURCHASES_PER_HOUR — about 4 BZZ a day against a wallet holding
16.

The bypass is now honoured only on a network recognised as mainnet.
X402_ALLOW_TESTNET_PAID_BYPASS=true overrides it so staging can still exercise
the paid path. An unrecognised network name counts as a testnet: refusing a real
payment is recoverable, giving away batches is not.

The payment is still settled and the batch still registered to the payer — only
the bypass is withheld, so the caller keeps its normal allowance instead of
being refused outright. The 429 message is conditional to match: where paying
would not help, it points at direct purchase rather than sending someone to a
path that takes their payment and still refuses them.

1120 passed, 25 skipped.
The message told the caller a direct purchase 'costs the same'. It does not
necessarily: pool acquires carry X402_POOL_MARKUP_PERCENT (100) while a direct
purchase carries X402_MARKUP_PERCENT (50). Say what is actually true — the
direct route is not drawn from the pool, so the limit does not apply — and
name the tradeoff that is real, which is latency.
* Withhold the pool allowance bypass for testnet payments

/pool/acquire lets a settled x402 payment bypass the daily allowance, on the
reasoning that the budget bounds what the operator gives away rather than what
someone has paid for. That reasoning only holds where the payment is worth
something.

Production runs X402_NETWORK=base-sepolia against the public x402.org
facilitator. USDC there is free from a faucet, so shipping the bypass unguarded
would have replaced a bounded giveaway with an unbounded one: any caller could
top up for nothing and draw batches bought with real BZZ, capped only by
STAMP_POOL_MAX_PURCHASES_PER_HOUR — about 4 BZZ a day against a wallet holding
16.

The bypass is now honoured only on a network recognised as mainnet.
X402_ALLOW_TESTNET_PAID_BYPASS=true overrides it so staging can still exercise
the paid path. An unrecognised network name counts as a testnet: refusing a real
payment is recoverable, giving away batches is not.

The payment is still settled and the batch still registered to the payer — only
the bypass is withheld, so the caller keeps its normal allowance instead of
being refused outright. The 429 message is conditional to match: where paying
would not help, it points at direct purchase rather than sending someone to a
path that takes their payment and still refuses them.

1120 passed, 25 skipped.

* Drop an unbacked price claim from the exhausted-allowance message

The message told the caller a direct purchase 'costs the same'. It does not
necessarily: pool acquires carry X402_POOL_MARKUP_PERCENT (100) while a direct
purchase carries X402_MARKUP_PERCENT (50). Say what is actually true — the
direct route is not drawn from the pool, so the limit does not apply — and
name the tradeoff that is real, which is latency.
@crtahlin
crtahlin merged commit a27caac into main Sep 8, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Premium pricing for stamp pool feature

1 participant